feat: Sovryn Perimeter Fee display on withdraw and close flows - #1151
Merged
Conversation
Ports the perimeter-fee UI to the public repo, rebased onto current develop (base moved 4971f72 -> 6cd8343). Shows the fee row, tooltip, and net 'You will receive' amount on lending withdrawals, borrower exits, Zero collateral withdrawal/close, and the surplus-claim view. Display is gated purely on on-chain state and fails hidden: the row renders only when the controller quotes an active policy with a non-zero rate and fee. While the perimeter is deployed-but-disabled (its state until SIP-0094 executes and the Exchequer enables charging), every form renders exactly as it does today. No feature flag, no env var. All user-facing copy says 'Perimeter fee' (renamed from the working title during this port, tests updated to pin the new copy). Internal identifiers and the on-chain surface-id constants are unchanged — the ids are keccak hashes verified against the deployed consumer contracts. The Spanish locale remains the app-wide stub (falls back to English), unchanged by this change.
✅ Deploy Preview for sovryn-dapp ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
🦋 Changeset detectedLatest commit: 9a1f8fd The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
The frontend quotes policy by surface id, and Phase 1 is being re-cut with
renamed ids, so these literals move with the contracts. Left alone the app
would resolve no policy and render nothing at all -- a silent blank, not an
error, which is the failure mode this file exists to prevent.
The preimages change shape as well as content: Phase 1 derived an id as
keccak256("COLFEE:" + name), and the re-cut hashes the name alone, with the
namespace carried inside the name. SURFACE_ZERO_WITHDRAW_COLL joins the set,
which Phase 1 charged on-chain without the app ever quoting it.
The test now pins both halves -- each id against its preimage AND against the
literal 32 bytes. The preimage assertion alone cannot catch a rename that
rewrites the constant and its own expectation in one pass, which is how a stale
prefix survived a full sweep before being caught by comparing against the
contracts.
16 suites, 93 tests passing.
No component reads it — the Zero collateral-withdrawal fee comes from the contract's own preview function, which resolves the surface on chain. The constant exists so the test can pin it, which is what catches an id drifting between the contracts and this app. Without the note the next reader removes it as dead code and the canary goes with it.
Found by a sharp-edges review of the fee display API.
The perimeter fails open on chain: an unreachable controller, a reverting call
or a surface carrying no policy all resolve to no fee. The hooks flattened that
into { active: false, rateBps: 0 } -- the same value a deliberately-zero rate
produces -- and the row renders nothing when no fee is shown. So "we could not
find out" and "there is no fee" were the same pixels: absence. On the Zero
status panel it was worse than absence, because that one puts a number on it
and would present the gross surplus as the amount you receive.
Two ways it misled a user rather than a developer:
- Every consumer destructured { active, rateBps } and dropped loading. The hook
returns its inactive fallback until the quote arrives, so the form promised
no fee during the first fetch of every page view.
- The catch swallowed reverts, RPC failures and decode errors alike, and the
result is negatively cached for the TTL. One blip pinned "no fee" for thirty
seconds while the chain would still charge.
The quote type now carries `unknown`, separate from a real answer of zero -- an
unset controller pointer stays a real answer, because the contract charges
nothing by construction in that case. getExitFeeDisplay is the single decision,
returns charged/none/unknown, and folds loading in so it cannot be forgotten.
ExitFeeRow takes `unknown` as a required prop, so the caller has to say which of
the two this is, and renders a labelled row with an em dash rather than silence.
Pre-existing in Phase 1, not introduced by the re-cut. 96 tests, up 3, pinning
each of the three states. The linter caught the one consumer I had wired the
flags into without using them, which turned out to be the one that displays a
number.
Second own review cycle, checking what the first fix missed. CloseCreditLine still routed through isExitFeeShown, so an unreadable quote fell to the same branch as a genuinely uncharged surface and the collateral figure was presented as the gross with no fee mentioned. Closing a line of credit returns collateral through a charged surface, so that is the wrong number to show without saying it might be. Same treatment as the other three consumers: the display decision goes through getExitFeeDisplay, and the unknown case attaches the tooltip that says the rate could not be read rather than staying silent. This is why the second cycle exists. The first pass fixed the shared row and three consumers and looked complete; the fourth reached the same state by a different route and nothing failed.
Four consumers independently called isExitFeeShown with raw fields and reached the same wrong conclusion — that a rate nobody could read is a rate of zero. Fixing them one at a time fixes today's four; it does not stop the fifth. The predicate is now module-private. getExitFeeDisplay is the only exported decision, and it takes the whole quote, so the state that was being dropped cannot be dropped. A future consumer that tries the old shortcut gets a compile error instead of a review finding. Its tests now run through the exported decision, and cover the two states the old predicate could not express at all: unknown, and still loading.
Second adversarial pass, on the fix from the first. Attaching an "unavailable" tooltip to the two Zero views was not enough: both still rendered a number, and the number was the gross. The surplus and the closing collateral both leave through a charged surface, so the gross is precisely what does NOT arrive. A tooltip beside a confident figure loses to the figure. Both now show an em dash when the rate could not be read. Also corrected the opposite error, which the same pass found: an explicit zero gross was being reported as unknown, so "fee unavailable" would have appeared on add-collateral and borrow forms, where nothing leaves and no fee is possible. That is a real answer of none, and now says so. The controller-pointer cache keeps a bounded staleness window at the moment governance pins the controller -- up to the 30s TTL of showing no fee while the chain has begun charging. Documented in place rather than papered over: it exists once, and the release order already covers it, since the dapp ships before charging is enabled. Closing it properly needs block-based invalidation in the shared cache, which is a wider change than this window justifies.
Spec-to-code compliance found this, and it is a regression I introduced today. FRONTEND_EXIT_FEE_UI_SPEC §3 is explicit: on a quote revert or a missing getter, render nothing — "fail-hidden, never fail-wrong" — and it calls out the not-yet-deployed case by name, because exitFeeController() does not exist on mainnet until the activation SIPs execute. Today's unknown state treated that revert as "could not read the rate", so every lending, borrow and Zero form would have grown a "Perimeter fee —" row on a chain where no perimeter exists. Shipping the dapp ahead of activation is the plan of record, so this would have been the state on day one for every user. The two failures were never the same thing, and the controller pointer tells them apart. A missing or reverting getter, or a pointer of zero, is a protocol without the perimeter: nothing is charged, the forms look untouched, and that is a real answer. Only once the pointer resolves is the perimeter live — and a preview or quote that fails after that genuinely means the rate is unknown, which is the case the earlier fix was for. Zero gets the same split by reading its own exitFeeController() first, so "before the perimeter ships" and "the perimeter is up but the preview failed" stop being one revert. Both halves now hold: nothing appears before activation, and after it a failed read never reads as "no fee".
`ExitFeeRow` requires `unknown` deliberately: a quote that could not be obtained and a quote of zero are indistinguishable in `active`/`rateBps`, so the caller has to say which one it holds. The four renders-nothing cases never passed it, and they are all quotes that WERE obtained. This failed only the production build, never the test matrix: `craco test` type-strips through babel and craco.config.js runs ts-loader with transpileOnly, so `yarn build` is the one place the project is type-checked — which is exactly where the Netlify deploy preview was failing.
`previewZeroCollWithdrawExitFee` does not revert when it cannot obtain a usable quote — it returns normally with active=false, a zero fee, and the reason. Reading `active` alone therefore turned "we could not ask" into "nothing is charged", and on the Zero views that prints the GROSS as the amount you will receive: precisely what does not arrive. INVALID_QUOTE and CONTROLLER_REVERT now resolve to unknown, so those views show the em-dash row the spec requires (§3, revised 2026-08-21) instead of a number the chain will not honour. Also gate CI on a production build. `yarn test` type-strips through babel and craco runs ts-loader with transpileOnly, so the only full type check happens during the build — which is how a missing required prop passed a green matrix and failed at the deploy gate. `tsc --noEmit` cannot serve here: TypeScript 4.8 cannot parse some dependencies' declaration files.
Drives the hook with SUCCESSFUL preview calls carrying each SkipReason, which is the shape the contract actually produces — a throwing mock exercises the already-covered path and says nothing about this one. Verified to bite: with the reason check removed, the three undetermined cases fail. Each undetermined case also asserts the preview was reached, because the catch-all returns the same shape and would otherwise let a throw anywhere upstream masquerade as a correct classification. That is not hypothetical: create-react-app sets resetMocks, so implementations attached at module scope are stripped before each test, and every case silently fell into the catch while appearing to pass.
Drops the "fee unavailable" state and its em-dash rows. The chain fails open: when it cannot quote it charges nothing and pays the gross, so a form that shows nothing in that case is telling the truth, and a row that hints at a fee it cannot name is not a message this product sends. This restores the spec's original acceptance rule (§3) and retires the 2026-08-21 revision that introduced the third state. Consequences, all in the same direction: - ExitFeeRow, CloseCreditLine and LOCStatus render the pre-perimeter form whenever the quote is loading, unobtainable, or says nothing is charged; - useZeroExitFee no longer inspects the preview's reason: active=false from a fail-open preview IS the answer, since execution takes the same path; - the "unavailable" copy is removed. Also fixes, as a side effect, the row appearing with "fee unavailable" on add-collateral and borrow, where nothing exits: with no third state there is nothing to show there.
…s net The shared cache keeps its state until a changed key's result lands, so on the first render after an account, pool or gross switch the hooks were still holding the previous key's quote — one frame in which another party's fee row could flash, or a charged row vanish. Every fetched quote is now stamped with the key it was fetched for, and a quote whose stamp does not match the key being asked about is reported as still loading, which the display treats as nothing charged. Contained in the two exit-fee hooks; the shared cache is untouched. The surplus net is a fixed gross and therefore exact; AmountRenderer was adding its own "~" whenever the value carried more decimals than shown. Comments that still described the retired "unavailable" rendering now say what happens: those outcomes are hidden.
…ached over the real one The lending hook answers UNCHARGED while the protocol contract is still loading, and the shared cache keeps that answer for the TTL. The cache key did not include readiness, so the rerun triggered by the contract landing found the same fresh entry — and a genuinely charged fee stayed hidden for up to 30 s after every page load, silently and only in production. The key now carries the protocol address, so the pre-load answer lives under its own key and the loaded one is actually fetched.
…r window honestly The Zero close view prints the preview's net verbatim. The on-chain hook re-derives net from gross and fee and charges nothing when they disagree, so the FE now mirrors that test: a preview whose fee exceeds the gross, whose net is not gross minus fee, or whose rate exceeds the cap is treated as uncharged and the rows stay hidden. Only a tampered RPC can produce such a quote; this stops it printing a receipt the chain will not honour. The comment on the controller-pointer cache claimed a 30 s window; a refetch also waits for the next observed block, so it is one TTL plus one block — about 60 s on RSK.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sovryn Perimeter Fee — fee display on withdraw and close flows
Shows the perimeter fee (rate, amount, and net "You will receive") on lending
withdrawals, borrower exits, Zero collateral withdrawal/close, and the surplus-claim
view. Part of the Security Perimeter phase 1 release (SIP-0094 @
340148c, sha256496b69a6761f4e41d5e551f9c9f3962570beaba9e4aeb66613265c97fe09c756— the shipping pin the three on-chain proposals carry in their descriptions).Dormant until governance acts
Display is gated purely on on-chain state and fails hidden: a fee row renders only
when the deployed ExitFeeController quotes an active policy with a non-zero rate and
fee for that surface. Until SIP-0094 executes and charging is enabled, every form
renders exactly as it does on
developtoday — no feature flag, no env var, nothing toconfigure. Once charging is enabled on-chain, the display lights up on its own (quotes
are cached for 30 seconds).
Merge timing — opposite of the contract PRs, deliberately
The contract PRs (Sovryn-smart-contracts#580, zero-contracts#10) merge only after
the SIP executes. This PR should merge and deploy before charging is enabled: the
UI is provably inert until then, and having it live first guarantees users see the fee
the moment it exists. The one ordering that must not happen is enabling charging while
the dapp still lacks this UI — fees would apply without being displayed.
Notes for reviewers
during this port; tests pin the new copy). Internal identifiers and the on-chain
surface-id constants are unchanged — the ids are keccak hashes verified against the
deployed consumer contracts.
develop.regression there.